Ejercicios para practicar para el 1er parcial
Link al código fuente de esta sección
En los siguientes ejercicios, se explorarán diversos aspectos de la programación concurrente utilizando Rust.
- La consigna detallará (qué implementar).
- Los resultados o comportamientos esperados se describirán a alto nivel (tests genéricos, sin código específico, o ejemplos de uso).
1. Fundamentos de Concurrencia en Rust
Instrucciones comunes:
- Implementar las soluciones utilizando las primitivas de concurrencia de la librería estándar de Rust (
std::thread
,std::sync
, etc.). - Prestar atención a la seguridad de hilos (
Send
,Sync
) y al manejo de datos compartidos. - Considerar el uso de
Mutex
,RwLock
,Arc
, yAtomics
según sea apropiado.
Ejercicio 1.1: Contador Concurrente y Condiciones de Carrera
Consigna
Implementar un struct Counter
que pueda ser compartido y modificado de forma segura por múltiples hilos.
- El
Counter
debe tener un métodonew(initial_value: i32) -> Self
. - Debe proveer un método
increment(&self)
que aumente su valor interno en 1. - Debe proveer un método
get_value(&self) -> i32
que retorne el valor actual.
Se deben considerar dos enfoques o discusiones:
- Analizar por qué una implementación ingenua que intente modificar directamente un
i32
compartido entre hilos sin sincronización no es segura o no compilaría fácilmente en Rust sinunsafe
. (Referencia:race_conditions.rs
muestra cómo Rust previene esto). - Implementar una versión segura del
Counter
utilizandostd::sync::Mutex
para proteger el acceso al valor. - (Opcional) Implementar una versión alternativa utilizando
std::sync::atomic::AtomicI32
.
Resultados esperados
- Al lanzar N hilos, donde cada hilo llama al método
increment()
M veces sobre una instancia compartida delCounter
(protegido conMutex
oAtomicI32
). - Después de que todos los hilos terminen, el valor final retornado por
get_value()
debe serinitial_value + (N * M)
. - La implementación debe ser segura frente a condiciones de carrera, garantizando que cada incremento se aplique correctamente.
Ejercicio 1.2: Cuenta Bancaria Concurrente
Consigna
Implementar una estructura que simule las operaciones de una cuenta bancaria, permitiendo depósitos, extracciones y consultas de saldo de forma concurrente y segura.
Definir un trait BankAccount
:
#![allow(unused)] fn main() { pub trait BankAccount { fn new(initial_balance: f64) -> Self; fn deposit(&self, amount: f64); fn withdraw(&self, amount: f64) -> Result<(), String>; // Retorna Ok o Err con mensaje fn get_balance(&self) -> f64; } }
Proveer dos implementaciones de este trait:
MutexBankAccount
: Utilizarstd::sync::Mutex<f64>
para gestionar el saldo.RwLockBankAccount
: Utilizarstd::sync::RwLock<f64>
para gestionar el saldo, permitiendo múltiples lecturas concurrentes.
Resultados esperados
- Múltiples hilos deben poder interactuar concurrentemente con una instancia de
MutexBankAccount
oRwLockBankAccount
. - Las operaciones de
deposit
ywithdraw
deben modificar el saldo de manera atómica. withdraw
debe retornar unErr
si los fondos son insuficientes, sin modificar el saldo. Si tiene éxito, retornaOk(())
.get_balance
debe retornar el saldo actual.- Ejemplo de escenario:
- Cuenta inicia con saldo 0.
- Hilo A deposita 100.
- Hilo B deposita 50.
- Hilo C intenta extraer 200 (debe fallar).
- Hilo D extrae 30.
- Saldo final esperado: 120.
- Todas las operaciones deben reflejarse correctamente sin importar la intercalación de los hilos.
Ejercicio 1.3: Suma Paralela de Elementos de un Vector
Consigna
Implementar una función sum_parallel(nums: &[i32], m: usize) -> i32
que calcule la suma de los elementos de un slice de enteros (&[i32]
) de forma paralela.
- El slice
nums
debe ser dividido enm
sub-slices (chunks) de tamaño lo más similar posible. - La suma de cada sub-slice debe ser calculada por un hilo (
std::thread
) separado. - La función debe esperar a que todos los hilos terminen, recolectar sus sumas parciales y retornar la suma total.
- Considerar el manejo de casos borde, como un vector vacío o
m=0
(debe entrar en pánico sim=0
om
es mayor que el número de elementos de una forma que no tenga sentido, aunque dividir en más chunks que elementos es posible).
Resultados esperados
sum_parallel(&[1, 2, 3, 4, 5], 2)
podría dividir el trabajo en[1, 2, 3]
y[4, 5]
, sumarlos en paralelo y retornar15
.sum_parallel(&[], m)
debe retornar0
para cualquierm > 0
.- El resultado de
sum_parallel
debe ser idéntico al de una suma secuencial (e.g.,nums.iter().sum()
). - La función debe entrar en pánico si
m
es 0 (como se muestra enparallel_vector_sum.rs
). - Evaluar el rendimiento (conceptualmente) en comparación con una suma secuencial para vectores grandes.
Ejercicio 1.4: Problema del Productor-Consumidor con Buffer Acotado
Consigna
Implementar una estructura BoundedBuffer<T>
que sirva como un canal de comunicación de capacidad limitada entre hilos productores y consumidores.
- La
BoundedBuffer<T>
debe encapsular los datos compartidos (e.g., unVecDeque<T>
para el buffer, su capacidad máxima y tamaño actual) protegidos por unstd::sync::Mutex
. - Debe utilizar dos
std::sync::Condvar
:not_empty
: Para que los hilos consumidores esperen si el buffer está vacío.not_full
: Para que los hilos productores esperen si el buffer está lleno.
- Se deben implementar (o se pueden proveer como en
bounded_buffer.rs
) structsProducer
yConsumer
que interactúen con elBoundedBuffer
:Producer::produce(&self, item: T)
: Añade unitem
al buffer. Si el buffer está lleno, el productor debe bloquearse (esperar ennot_full
). Al producir, notifica a través denot_empty
.Consumer::consume(&self) -> T
: Extrae unitem
del buffer. Si el buffer está vacío, el consumidor debe bloquearse (esperar ennot_empty
). Al consumir, notifica a través denot_full
.
Resultados esperados
- Múltiples hilos productores y consumidores pueden operar concurrentemente sobre la misma instancia de
BoundedBuffer
sin corrupción de datos ni deadlocks. - Los productores se bloquean eficazmente cuando el buffer alcanza su capacidad máxima y se reanudan cuando se libera espacio.
- Los consumidores se bloquean eficazmente cuando el buffer está vacío y se reanudan cuando se añaden nuevos ítems.
- Los ítems producidos son consumidos correctamente (sin pérdidas ni duplicados).
- El uso de
std::thread::sleep
dentro deproduce
/consume
(como en el ejemplo) puede ayudar a visualizar la alternancia y el bloqueo/desbloqueo de los hilos.
Ejercicio 1.5: Implementación de un Buffer Circular Concurrente
Consigna
Desarrollar una estructura ConcurrentCircularBuffer<T>
que implemente un buffer circular con semántica de productor-consumidor, seguro para acceso concurrente.
- La estructura interna debe manejar un buffer de tamaño fijo (e.g.,
Vec<Option<T>>
) con punteroshead
ytail
para la lógica circular, y contadores desize
ycapacity
. - Los datos compartidos (buffer, punteros, contadores) deben estar protegidos por un
std::sync::Mutex
. - Se deben emplear dos
std::sync::Condvar
(not_empty
ynot_full
) para la sincronización:- Método
add(&self, item: T)
: Si el buffer está lleno, el hilo productor debe esperar en laCondvar
not_full
. Tras añadir el ítem, debe notificar a un posible consumidor mediantenot_empty.notify_one()
(onotify_all()
). - Método
remove(&self) -> T
: Si el buffer está vacío, el hilo consumidor debe esperar en laCondvar
not_empty
. Tras extraer el ítem, debe notificar a un posible productor mediantenot_full.notify_one()
(onotify_all()
).
- Método
- (Opcional) Considerar la implementación base de un
CircularBuffer<T>
no concurrente primero. - Revisar la lógica de notificación: asegurarse de que se notifica a la
Condvar
correcta (e.g., encircular_buffer.rs
elremove
original podría tenernot_empty.notify_one()
donde debería sernot_full.notify_one()
).
Resultados esperados
- El
ConcurrentCircularBuffer
permite la interacción segura entre múltiples productores y consumidores. - Se previene el desbordamiento (overflow) y el subdesbordamiento (underflow) del buffer mediante el bloqueo y desbloqueo correcto de los hilos.
- Las
Condvar
se utilizan eficazmente para minimizar la espera activa (busy-waiting). - El comportamiento es robusto frente a condiciones de carrera.
Ejercicio 1.6: Cola Concurrente - De Busy-Waiting a Condvar
Consigna
Explorar y mejorar la implementación de una cola (queue) concurrente simple, pasando de un enfoque de espera activa (busy-waiting) a uno más eficiente usando variables de condición (Condvar
).
-
Análisis del Busy-Waiting:
- Examinar el código de
queue_behaviour()
(presente enqueue.rs
), que utiliza unMutex<VecDeque<T>>
. - El hilo consumidor intenta extraer elementos en un bucle, adquiriendo el lock repetidamente incluso si la cola está vacía (busy-waiting).
- Identificar las desventajas de este enfoque (principalmente, consumo innecesario de CPU).
- Examinar el código de
-
Implementación con
Condvar
:- Modificar o re-implementar la lógica en una función similar a
queue_behaviour_with_condvar()
. - Además del
Mutex
para laVecDeque
, introducir unastd::sync::Condvar
(e.g.,not_empty
). - El hilo consumidor, si encuentra la cola vacía, debe esperar en la
Condvar
(condvar.wait(lock_guard)
). Es crucial que esta espera se realice dentro de un bucle que vuelva a comprobar la condición de la cola tras despertar, para manejar despertares espurios (spurious wakeups). - El hilo productor, después de añadir un elemento a la cola, debe notificar a un consumidor en espera (
condvar.notify_one()
).
- Modificar o re-implementar la lógica en una función similar a
Resultados esperados
- Al ejecutar la versión con busy-waiting, se observa un alto uso de CPU por parte del hilo consumidor, especialmente cuando la cola está vacía frecuentemente.
- Al ejecutar la versión con
Condvar
, el hilo consumidor debe mostrar un uso de CPU significativamente menor cuando está esperando, ya que el hilo se bloquea en lugar de sondear activamente. - Ambas versiones deben transferir correctamente los ítems del productor al consumidor, pero la versión con
Condvar
debe hacerlo de manera mucho más eficiente en términos de recursos del sistema.
Ejercicio 1.7: Comunicación Multi-Productor a Consumidor Único con Canales MPSC
Consigna
Implementar un patrón de concurrencia donde múltiples hilos productores envían datos a un único hilo consumidor utilizando los canales multi-productor, single-consumer (mpsc
) de la librería estándar de Rust (std::sync::mpsc
).
- Crear un canal
mpsc::channel()
. - Lanzar N hilos productores. Cada productor debe:
- Obtener una copia clonada del extremo
Sender<T>
del canal. - Enviar uno o más mensajes (e.g., un ID de hilo, un dato calculado, etc.) a través de su
Sender
. - Asegurarse de que el
Sender
clonado se libere (drop
) cuando el productor haya terminado de enviar mensajes para permitir que el canal se cierre eventualmente.
- Obtener una copia clonada del extremo
- El hilo consumidor debe:
- Utilizar el extremo
Receiver<T>
del canal. - Recibir mensajes en un bucle. El bucle debe terminar cuando el canal se cierre (es decir, cuando todos los
Sender
s hayan sido liberados). - Procesar o imprimir cada mensaje recibido.
- Utilizar el extremo
Resultados esperados
- El hilo consumidor recibe todos los mensajes enviados por todos los hilos productores.
- El programa termina correctamente después de que todos los mensajes han sido procesados y el canal se ha cerrado (el
Receiver
ya no puede obtener más mensajes). - Se puede observar cómo los mensajes de diferentes productores pueden intercalarse al ser recibidos por el consumidor, dependiendo del scheduling de los hilos.
- Este ejercicio demuestra un caso de uso fundamental de los canales
mpsc
para la agregación de datos o tareas desde múltiples fuentes.
Ejercicio 1.8: Pipeline de Procesamiento de N Etapas con Canales
Consigna
Construir un pipeline de procesamiento de datos donde un dato inicial atraviesa una secuencia de N etapas de transformación, cada una ejecutándose en un hilo separado y comunicándose con la siguiente mediante canales mpsc
.
- Definir una serie de funciones de transformación (e.g.,
fn(T) -> T
). - Implementar una estructura o lógica (como la
Pipeline
yPipelineNode
enchannels.rs
) que:- Tome una lista de estas funciones de transformación.
- Para cada función, cree un hilo (una etapa del pipeline).
- Cree un canal
mpsc
entre cada par de etapas consecutivas (la salida de la etapai
es la entrada de la etapai+1
). - El primer hilo del pipeline recibe un valor inicial (posiblemente a través de un
Sender
inicial). - Cada hilo intermedio recibe un valor de su predecesor, aplica su función de transformación, y envía el resultado a su sucesor.
- El último hilo del pipeline envía su resultado a un
Receiver
final desde donde se puede obtener el resultado global.
- La función
run
del pipeline debe tomar un valor inicial, enviarlo a la primera etapa, y retornar el valor recibido de la última etapa.
Resultados esperados
- Un valor de entrada es procesado secuencialmente por todas las etapas del pipeline, con cada transformación ocurriendo en un hilo dedicado.
- El resultado final obtenido es el producto de aplicar todas las funciones de transformación en el orden especificado.
- Ejemplo: Si el valor inicial es
X
y las etapas sonf1, f2, f3
, el resultado final debe serf3(f2(f1(X)))
. - El sistema debe manejar la creación, el enlace y el cierre de los canales correctamente. (Considerar cómo se manejan los errores o pánicos en una etapa; el ejemplo en
channels.rs
usaexpect
yunwrap
).
Ejercicio 1.9: El Problema de los Filósofos Cenadores
Consigna
Implementar una simulación del clásico problema de concurrencia de los "Filósofos Cenadores", con el objetivo de evitar deadlocks y permitir que los filósofos coman.
- Habrá N filósofos sentados en una mesa redonda. Entre cada par de filósofos adyacentes hay un tenedor (N tenedores en total).
- Cada filósofo alterna entre dos estados: pensar y comer.
- Para comer, un filósofo necesita adquirir los dos tenedores que tiene a su lado (el izquierdo y el derecho).
- La implementación debe incluir:
- Un struct
Philosopher
que represente a un filósofo, con su ID (o posición) y la lógica para pensar y comer. - Un struct
Table
(o similar) para representar el estado compartido de los tenedores. Este estado (e.g., unVec<bool>
indicando si cada tenedor está disponible) debe ser protegido por unstd::sync::Mutex
. - Una
std::sync::Condvar
para que los filósofos puedan esperar de manera eficiente si los tenedores que necesitan no están disponibles.
- Un struct
- Lógica para comer (método
eat
delPhilosopher
):- Adquirir el lock del
Mutex
de la mesa. - Mientras ambos tenedores necesarios (izquierdo y derecho) no estén disponibles: esperar en la
Condvar
(condvar.wait(lock_guard)
). La guarda del mutex se libera mientras se espera y se vuelve a adquirir al despertar. - Cuando ambos tenedores estén disponibles (tras despertar y re-evaluar la condición), marcarlos como "en uso".
- Liberar el lock del
Mutex
de la mesa (importante: esto permite a otros filósofos intentar tomar tenedores mientras este come). - Simular el tiempo de comida (e.g.,
std::thread::sleep
). - Volver a adquirir el lock del
Mutex
de la mesa. - Marcar ambos tenedores como "disponibles".
- Notificar a todos los demás filósofos que podrían estar esperando (
condvar.notify_all()
). - Liberar el lock del
Mutex
.
- Adquirir el lock del
Resultados esperados
- La simulación se ejecuta con N filósofos (e.g., 5) y N tenedores.
- Los filósofos pueden alternar entre pensar y comer sin que el sistema entre en deadlock (donde ningún filósofo puede progresar).
- Se demuestra que los filósofos esperan si no pueden obtener ambos tenedores y son notificados cuando los tenedores se liberan.
- (Opcional) Considerar y discutir brevemente otras estrategias para la prevención de deadlocks en este problema (e.g., ordenamiento global de adquisición de tenedores, limitar el número de comensales).
Ejercicio 1.10: Implementación de Merge Sort Paralelo
Consigna
Desarrollar una versión paralela del algoritmo de ordenamiento Merge Sort para un slice de enteros (&[i32]
).
- La implementación debe incluir las siguientes partes:
- Una función
merge(first_slice: &[i32], second_slice: &[i32]) -> Vec<i32>
: Esta función toma dos slices ya ordenados y los combina en un nuevoVec<i32>
que también está ordenado. - (Opcional pero útil como referencia) Una función
sequential_merge_sort(slice: &[i32]) -> Vec<i32>
: La implementación recursiva estándar de Merge Sort. - Una función
parallel_merge_sort(slice: &[i32]) -> Vec<i32>
: Esta es la versión paralela.- Debe manejar el caso base: si el slice es suficientemente pequeño (e.g., longitud 0 o 1), se retorna directamente o se ordena secuencialmente.
- Para el paso recursivo: dividir el slice en dos mitades.
- Ordenar al menos una de las mitades en un nuevo hilo. Por ejemplo, la primera mitad puede ordenarse en el hilo actual (recursivamente, podría ser secuencial o paralelo dependiendo de la profundidad y un umbral), y la segunda mitad se ordena en un hilo spawnneado (
std::thread::scope
es útil aquí). - Esperar a que el hilo (o hilos) terminen y obtener las dos mitades ordenadas.
- Combinar las dos mitades ordenadas usando la función
merge
.
- Una función
Resultados esperados
parallel_merge_sort
debe producir un vector correctamente ordenado, idéntico al resultado de un Merge Sort secuencial.- Debe funcionar para diversos casos de prueba: arrays vacíos, de un elemento, ya ordenados, en orden inverso, con elementos duplicados, etc.
- Para arrays de tamaño considerable, la versión
parallel_merge_sort
debería, idealmente, ejecutarse más rápido que una versión puramente secuencial. Esto se puede verificar mediante benchmarking (comparando tiempos de ejecución). - (Para discusión) Considerar el uso de un umbral: si el tamaño del sub-array a ordenar es menor que cierto umbral, cambiar a una versión secuencial para evitar el overhead de crear hilos para tareas muy pequeñas. También, discutir cómo se podría aumentar el grado de paralelismo (e.g., lanzando ambas mitades a hilos separados si el umbral lo permite).
Ejercicio 1.11: Suma de Matrices Secuencial y Paralela
Consigna
Implementar una estructura Matrix
para representar matrices de números de punto flotante (f64
) y desarrollar métodos para su suma, tanto de forma secuencial como paralela.
- Definir un struct
Matrix
que encapsule los datos de la matriz (e.g.,Vec<Vec<f64>>
). - Implementar las siguientes funcionalidades para la suma de dos matrices (
self
yother: &Matrix
):- Suma Secuencial (
add_sequential
):- Debe sumar las dos matrices elemento por elemento y retornar una nueva
Matrix
con el resultado. - La operación asume que ambas matrices tienen las mismas dimensiones. (Considerar añadir una verificación explícita de dimensiones y manejar el error si no coinciden, e.g., retornando un
Result<Matrix, String>
o causando unpanic
controlado).
- Debe sumar las dos matrices elemento por elemento y retornar una nueva
- Suma Paralela (
add_parallel
):- Debe realizar la suma de las dos matrices utilizando hilos para paralelizar el cálculo.
- Una estrategia común es asignar a cada hilo el cálculo de una fila completa (o un subconjunto de filas) de la matriz resultante.
std::thread::scope
puede ser útil para gestionar los hilos. - Al igual que la suma secuencial, esta operación asume dimensiones compatibles.
- Suma Secuencial (
- (Opcional) Crear una enumeración
OperationMethod { SEQUENTIAL, PARALLEL }
y un método principaladd_matrix(&self, other: &Matrix, method: OperationMethod) -> Matrix
que delegue a la implementación correspondiente.
Resultados esperados
- Para dos matrices de entrada dadas, tanto
add_sequential
comoadd_parallel
deben producir la mismaMatrix
resultante. - El código debe manejar correctamente matrices de diversas dimensiones (e.g., cuadradas, rectangulares, 1x1) y con diferentes tipos de valores (positivos, negativos, cero).
- Para matrices de tamaño suficientemente grande, la versión
add_parallel
debería mostrar una mejora en el tiempo de ejecución en comparación conadd_sequential
. Esto se puede verificar mediante benchmarking. - Si se implementa la verificación de dimensiones, las operaciones deben fallar de forma predecible (e.g., retornar
Err
opanic
) cuando se intentan sumar matrices de dimensiones incompatibles. - (Para discusión) Explorar y comparar diferentes granularidades para la paralelización: ¿por fila, por columna, por bloques de elementos? ¿Cuál podría ser más eficiente y por qué?